Add support for speculative contacts#2366
Conversation
📝 WalkthroughWalkthroughAdds speculative contact detection and frame-to-frame rigid contact matching to the collision pipeline, exposes a public SpeculativeContactConfig, wires speculative parameters through broad- and narrow-phase kernels, updates API signatures, adds tests, and documents the features. Changes
Sequence Diagram(s)sequenceDiagram
participant Model
participant CollisionPipeline
participant Broadphase as AABB Kernels
participant NarrowPhase as Narrow-phase Kernels
participant ContactMatcher
participant Contacts
Model->>CollisionPipeline: collide(state, dt?, speculative_config?)
CollisionPipeline->>Broadphase: run broad-phase (select speculative/non-speculative AABB)
Broadphase-->>CollisionPipeline: candidate pairs + per-shape AABBs
CollisionPipeline->>NarrowPhase: launch narrow-phase (speculative flags, per-shape vel/ang bounds)
NarrowPhase-->>CollisionPipeline: raw contacts (with match keys)
alt contact matching enabled
CollisionPipeline->>ContactMatcher: match current vs previous sorted contacts
ContactMatcher-->>CollisionPipeline: match indices / new/broken lists
CollisionPipeline->>Contacts: write matched/filtered contacts + report buffers
else contact matching disabled
CollisionPipeline->>Contacts: write contacts
end
CollisionPipeline-->>Model: return Contacts
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
newton/tests/test_speculative_contacts.py (1)
138-165: Please add a linear GJK regression alongside the angular one.The suite covers linear motion only on sphere-based paths and covers boxes only with angular motion. A simple approaching/diverging box-box case would exercise the remaining non-primitive speculative acceptance branch and would catch regressions in the directed
write_contact()path much earlier.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@newton/tests/test_speculative_contacts.py` around lines 138 - 165, Add a second test inside (or next to) test_speculative_angular_velocity that performs a linear GJK regression for box-box speculative contacts: reuse the newton.ModelBuilder setup (builder, builder.rigid_gap = 0.0), create two box bodies (similar to body_a and body_b) positioned apart on the x-axis, set their body_qd linear velocity components so they are approaching each other (e.g., nonzero vx on body_a or opposite vx on body_b) while keeping angular rates zero, finalize the model and state, create the same newton.SpeculativeContactConfig and newton.CollisionPipeline (broad_phase="nxn", speculative_config=config), call pipeline.collide(state, contacts) and assert contacts.rigid_contact_count.numpy()[0] > 0 to validate linear speculative acceptance; name the test to indicate linear GJK regression (e.g., test_speculative_linear_velocity) so it exercises the non-primitive speculative acceptance branch and directed write_contact path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@newton/_src/geometry/narrow_phase.py`:
- Around line 242-245: The current speculative widening uses raw relative speed
(vel_rel / |v_rel| via rel_speed) to increase gap_sum (using vel_rel, rel_speed,
gap_sum, speculative_dt, max_speculative_extension), which can accept
diverging/tangential pairs; change this so the rel_speed-based widening is used
only for candidate generation and not as the final acceptance metric: keep the
existing conservative addition to gap_sum for early pruning in the speculative
branch (both where vel_rel/rel_speed is used and in the duplicate primitive path
around the same logic), but ensure the final contact_passes_gap_check uses the
approach-speed projected onto the resolved contact normal (compute approach =
dot(vel_rel, contact_normal) + angular bounds) and use that projection for the
final gap acceptance instead of |v_rel|.
In `@newton/_src/sim/collide.py`:
- Around line 181-205: The speculative AABB kernel compute_shape_aabbs never
applies the configured max_speculative_extension, so add a kernel parameter
(e.g. max_speculative_extension: float) to compute_shape_aabbs (and the other
similar kernel variants referenced around the other occurrences) and clamp the
computed speculative expansion to that value before adding it to
aabb_lower/aabb_upper; locate where shape_lin_vel/shape_ang_speed_bound and
speculative_dt are used to compute the linear/angular expansions and insert a
min(extension, max_speculative_extension) (or equivalent) there so the
broad-phase expansion is bounded by the user-configured cap.
- Around line 31-47: Ensure speculative parameters are validated and reject
invalid values before any kernel/AABB math: add checks that
SpeculativeContactConfig.max_speculative_extension and
SpeculativeContactConfig.collision_update_dt are finite and non-negative (and >0
for dt) when creating/using the config, and in CollisionPipeline.collide(dt=...)
validate the per-call dt similarly; if any value is negative, zero, or
non-finite, raise a clear exception (or disable speculative extension
explicitly) so the invalid values never flow into AABB/gap computations or
kernel launches.
- Around line 123-136: The approach-speed projection is reversed: compute
approach speed so approaching motion yields a positive value and only then
extend the speculative gap. Change the v_approach calculation to use the negated
linear projection (e.g., use wp.dot(vel_a - vel_b, contact_normal_a_to_b) or
negate wp.dot(vel_b - vel_a, contact_normal_a_to_b)) while keeping the angular
bounds, then compute spec_gap = wp.min(wp.max(v_approach *
writer_data.speculative_dt, 0.0), writer_data.max_speculative_extension) and add
it to contact_gap; update the v_approach variable and usage accordingly
(symbols: writer_data.speculative_dt, vel_a, vel_b, contact_normal_a_to_b,
v_approach, spec_gap, writer_data.max_speculative_extension, contact_gap).
---
Nitpick comments:
In `@newton/tests/test_speculative_contacts.py`:
- Around line 138-165: Add a second test inside (or next to)
test_speculative_angular_velocity that performs a linear GJK regression for
box-box speculative contacts: reuse the newton.ModelBuilder setup (builder,
builder.rigid_gap = 0.0), create two box bodies (similar to body_a and body_b)
positioned apart on the x-axis, set their body_qd linear velocity components so
they are approaching each other (e.g., nonzero vx on body_a or opposite vx on
body_b) while keeping angular rates zero, finalize the model and state, create
the same newton.SpeculativeContactConfig and newton.CollisionPipeline
(broad_phase="nxn", speculative_config=config), call pipeline.collide(state,
contacts) and assert contacts.rigid_contact_count.numpy()[0] > 0 to validate
linear speculative acceptance; name the test to indicate linear GJK regression
(e.g., test_speculative_linear_velocity) so it exercises the non-primitive
speculative acceptance branch and directed write_contact path.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yml
Review profile: CHILL
Plan: Pro
Run ID: 789f46d6-07bb-4f8e-a93c-f470a41c5965
📒 Files selected for processing (6)
newton/__init__.pynewton/_src/geometry/narrow_phase.pynewton/_src/sim/__init__.pynewton/_src/sim/collide.pynewton/_src/sim/model.pynewton/tests/test_speculative_contacts.py
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/concepts/collisions.rst`:
- Around line 1120-1122: The docs sentence claiming speculative widening uses a
"directed approach speed along the contact normal" is incorrect; update the
description to reflect that the narrow-phase widening uses the sum of relative
linear speed magnitude and angular-speed bounds (then clamped by
max_speculative_extension) rather than a normal-projected approach term —
mention the actual computation (relative linear speed magnitude + angular-speed
bounds, clamped by max_speculative_extension) and remove or correct the phrase
"directed approach speed along the contact normal" so readers get accurate
tuning guidance referencing max_speculative_extension and the angular-speed
bounds logic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yml
Review profile: CHILL
Plan: Pro
Run ID: 065ae639-d5f5-4033-9bbc-7c6b670c599e
📒 Files selected for processing (3)
CHANGELOG.mddocs/concepts/collisions.rstnewton/_src/sim/collide.py
🚧 Files skipped from review as they are similar to previous changes (1)
- newton/_src/sim/collide.py
There was a problem hiding this comment.
🧹 Nitpick comments (1)
newton/_src/geometry/narrow_phase.py (1)
1768-1772: Consider caching the empty speculative arrays.These empty arrays are allocated on every
launch_custom_writecall. SinceNarrowPhasealready caches similar sentinel buffers (e.g.,_empty_edge_indices,_empty_edge_range), the speculative fallback arrays could be cached in__init__to avoid repeated allocations.♻️ Suggested caching in __init__
# In __init__, after allocating other sentinel buffers: + # Sentinel arrays for speculative fallback (when not provided) + self._empty_lin_vel = wp.empty(0, dtype=wp.vec3, device=device) + self._empty_ang_speed = wp.empty(0, dtype=wp.float32, device=device)# In launch_custom_write: - _empty_vec3 = wp.empty(0, dtype=wp.vec3, device=device) - _empty_float = wp.empty(0, dtype=wp.float32, device=device) - _slv = shape_lin_vel if shape_lin_vel is not None else _empty_vec3 - _sasb = shape_ang_speed_bound if shape_ang_speed_bound is not None else _empty_float + _slv = shape_lin_vel if shape_lin_vel is not None else self._empty_lin_vel + _sasb = shape_ang_speed_bound if shape_ang_speed_bound is not None else self._empty_ang_speed🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@newton/_src/geometry/narrow_phase.py` around lines 1768 - 1772, Create and cache the empty speculative arrays in NarrowPhase.__init__ (e.g., self._empty_spec_vec3 = wp.empty(0, dtype=wp.vec3, device=self.device) and self._empty_spec_float = wp.empty(0, dtype=wp.float32, device=self.device) using the same device as the instance), then update launch_custom_write to use those cached sentinels instead of allocating new ones: replace local allocations of _empty_vec3/_empty_float with the cached attributes and keep the same fallback logic for _slv (shape_lin_vel) and _sasb (shape_ang_speed_bound). Ensure the cached arrays match dtype and device so existing code that references _slv/_sasb continues to work.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@newton/_src/geometry/narrow_phase.py`:
- Around line 1768-1772: Create and cache the empty speculative arrays in
NarrowPhase.__init__ (e.g., self._empty_spec_vec3 = wp.empty(0, dtype=wp.vec3,
device=self.device) and self._empty_spec_float = wp.empty(0, dtype=wp.float32,
device=self.device) using the same device as the instance), then update
launch_custom_write to use those cached sentinels instead of allocating new
ones: replace local allocations of _empty_vec3/_empty_float with the cached
attributes and keep the same fallback logic for _slv (shape_lin_vel) and _sasb
(shape_ang_speed_bound). Ensure the cached arrays match dtype and device so
existing code that references _slv/_sasb continues to work.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yml
Review profile: CHILL
Plan: Pro
Run ID: 3b97f6cd-435e-430f-8a20-26d558595cfb
📒 Files selected for processing (4)
CHANGELOG.mddocs/concepts/collisions.rstnewton/_src/geometry/narrow_phase.pynewton/_src/sim/collide.py
🚧 Files skipped from review as they are similar to previous changes (2)
- CHANGELOG.md
- docs/concepts/collisions.rst
…vs mesh collisions
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
newton/_src/geometry/contact_sort.py (1)
1-1:⚠️ Potential issue | 🟡 MinorKeep the original SPDX creation year here.
newton/_src/geometry/contact_sort.pyis an existing file, so changing this header to2026loses the file's creation year.As per coding guidelines, "
**/*.{py,yml,yaml}: In SPDX copyright lines, use the year the file was first created. Do not create date ranges or update the year when modifying a file."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@newton/_src/geometry/contact_sort.py` at line 1, The SPDX header in newton/_src/geometry/contact_sort.py was changed to 2026 but must retain the file's original creation year; revert the SPDX-FileCopyrightText line in contact_sort.py to the original single year (not a range) that the file was first created so the header matches repository policy.
♻️ Duplicate comments (4)
newton/_src/geometry/narrow_phase.py (2)
880-894:⚠️ Potential issue | 🟠 MajorSpeculative heightfield pairs still use the non-speculative midphase.
This branch still calls
heightfield_vs_convex_midphase()with onlyshape_gap, so the speculative extension never widens the heightfield cell query. Fast convexs can still cross into a new heightfield cell between frames without emitting the triangle pairs needed for the later GJK/MPR pass.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@newton/_src/geometry/narrow_phase.py` around lines 880 - 894, The heightfield branch is calling heightfield_vs_convex_midphase with only shape_gap, so speculative extension isn't applied; change the call to pass the speculative-widened gap (e.g., shape_gap + shape_speculative_extension or a dedicated shape_speculative_gap) and update heightfield_vs_convex_midphase's signature/uses to accept and apply that speculative gap when building the heightfield cell query; keep the j!=0 early-continue logic and locate this change around GeoType.HFIELD, heightfield_data, shape_heightfield_index and the heightfield_vs_convex_midphase invocation.
1835-1862:⚠️ Potential issue | 🟠 Major
NarrowPhase.launch()still can't drive speculative contacts.
launch_custom_write()now acceptsshape_lin_vel,shape_ang_speed_bound,speculative_dt, andmax_speculative_extension, but this wrapper neither exposes nor forwards them. Any caller that still useslaunch()has no path to enable the new feature.🧩 Suggested fix
def launch( self, *, @@ contact_penetration: wp.array[float], # negative if bodies overlap contact_count: wp.array[int], # Number of active contacts after narrow contact_tangent: wp.array[wp.vec3] | None = None, # Represents x axis of local contact frame (None to disable) + shape_lin_vel: wp.array[wp.vec3] | None = None, + shape_ang_speed_bound: wp.array[wp.float32] | None = None, + speculative_dt: float = 0.0, + max_speculative_extension: float = 0.0, device: Devicelike | None = None, # Device to launch on **kwargs: Any, ) -> None: @@ self.launch_custom_write( candidate_pair=candidate_pair, candidate_pair_count=candidate_pair_count, @@ mesh_edge_indices=mesh_edge_indices, shape_edge_range=shape_edge_range, writer_data=writer_data, + shape_lin_vel=shape_lin_vel, + shape_ang_speed_bound=shape_ang_speed_bound, + speculative_dt=speculative_dt, + max_speculative_extension=max_speculative_extension, device=device, )Also applies to: 2312-2448
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@newton/_src/geometry/narrow_phase.py` around lines 1835 - 1862, The NarrowPhase.launch() wrapper does not expose or forward the new speculative-contact parameters to launch_custom_write (shape_lin_vel, shape_ang_speed_bound, speculative_dt, max_speculative_extension), so callers cannot enable speculative contacts; update the NarrowPhase.launch() signature to accept those four parameters (with same defaults) and pass them through when calling launch_custom_write, and repeat the same change for the other wrapper overload around the later block (the one covering lines ~2312-2448) so both wrappers expose and forward shape_lin_vel, shape_ang_speed_bound, speculative_dt, and max_speculative_extension to launch_custom_write.newton/_src/sim/collide.py (2)
928-935:⚠️ Potential issue | 🟠 MajorDeterministic mode still enables for hydroelastic scenes.
The docstring warns that hydroelastic contacts are not yet covered, but this branch still enables deterministic sorting for them — and in hydroelastic write paths multiple contacts per shape pair share the same sort key, so their relative order remains launch-dependent and results in nondeterministic
rigid_contact_*buffers. Additionally, since enablingcontact_matchingnow force-setsdeterministic=True, a user could unintentionally activate this path on a hydroelastic scene just by opting into matching.Consider rejecting the combination explicitly:
🛡️ Suggested guard
self.requires_grad = requires_grad self.deterministic = deterministic per_contact_props = self.narrow_phase.hydroelastic_sdf is not None if deterministic: + if per_contact_props: + raise ValueError( + "CollisionPipeline(deterministic=True) does not support " + "hydroelastic contacts yet." + ) with wp.ScopedDevice(device):🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@newton/_src/sim/collide.py` around lines 928 - 935, The deterministic branch is being enabled for hydroelastic scenes (per_contact_props is True when self.narrow_phase.hydroelastic_sdf is set), which is invalid because hydroelastic contacts produce multiple contacts per pair that keep launch-dependent ordering; update the guard in the constructor around the deterministic/contact-sorting logic (the block creating self._sort_key_array and self._contact_sorter/ContactSorter) to explicitly reject or disable deterministic mode when self.narrow_phase.hydroelastic_sdf is present (and also when contact_matching forces deterministic=True), e.g. raise a clear exception or fallback to deterministic=False if per_contact_props is True; reference the deterministic flag, per_contact_props, self.narrow_phase.hydroelastic_sdf, ContactSorter and the code path that force-sets deterministic via contact_matching to locate and adjust the logic.
767-810:⚠️ Potential issue | 🟠 MajorSpeculative setting mismatch still not validated in the expert-components branch.
When a caller supplies a prebuilt
narrow_phase, the constructor only threadsspeculative=self._speculative_enabledthrough in the non-expert path (line 894). In the expert branch this check is skipped: if the passed-innarrow_phasewas built withspeculative=Falsebutspeculative_configis notNone, speculative expansion inwrite_contact/AABBs silently no-ops; if it was built withspeculative=Truebutspeculative_config is None,collide()still passes emptyshape_lin_vel/shape_ang_speed_boundarrays, and any kernel compiled to index them will OOB.Please validate the supplied narrow phase’s speculative setting against
self._speculative_enabledhere, before the sanity checks onmax_candidate_pairs:🛡️ Suggested guard
if deterministic and not narrow_phase.deterministic: raise ValueError( "CollisionPipeline(deterministic=True) requires a deterministic " "NarrowPhase. Either omit narrow_phase or construct it with " "deterministic=True." ) + if getattr(narrow_phase, "speculative", False) != self._speculative_enabled: + raise ValueError( + "speculative_config must match the supplied NarrowPhase.speculative " + f"setting (config enabled={self._speculative_enabled}, " + f"narrow_phase.speculative={getattr(narrow_phase, 'speculative', False)})" + )🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@newton/_src/sim/collide.py` around lines 767 - 810, The expert-component branch fails to validate that the supplied NarrowPhase's speculative setting matches the pipeline's speculative enablement; before the max_candidate_pairs check, assert that the provided narrow_phase has a boolean attribute speculative and that narrow_phase.speculative == self._speculative_enabled, and if not raise a ValueError describing the mismatch (e.g., ask to pass a narrow_phase built with speculative matching CollisionPipeline(_speculative_enabled) or adjust speculative_config); reference the symbols narrow_phase, self._speculative_enabled, and self.narrow_phase and perform this check prior to the existing narrow_phase.max_candidate_pairs validation.
🧹 Nitpick comments (4)
newton/_src/sim/collide.py (2)
1067-1075: Validate the per-calldtoverride before using it.
speculative_dt = dt if dt is not None else cfg.collision_update_dtexecutes before validation, so a negative/NaN caller-supplieddtis already bound tospeculative_dtby the time the check runs. TheValueErrorstill fires before any kernel launch so this is not an active bug, but flipping the order makes the intent clearer and prevents future refactors from accidentally using the unchecked value:🧹 Suggested reorder
- if self._speculative_enabled: - cfg = self.speculative_config - speculative_dt = dt if dt is not None else cfg.collision_update_dt - if dt is not None: - _validate_speculative_scalar(dt, "dt") - max_speculative_extension = cfg.max_speculative_extension + if self._speculative_enabled: + cfg = self.speculative_config + if dt is not None: + _validate_speculative_scalar(dt, "dt") + speculative_dt = dt if dt is not None else cfg.collision_update_dt + max_speculative_extension = cfg.max_speculative_extension🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@newton/_src/sim/collide.py` around lines 1067 - 1075, The per-call dt should be validated before being assigned to speculative_dt; move the _validate_speculative_scalar(dt, "dt") check to occur when dt is not None and before computing speculative_dt in the block that checks self._speculative_enabled (using speculative_config, speculative_dt, and max_speculative_extension) so that any caller-supplied dt is validated first and only then used to set speculative_dt (falling back to cfg.collision_update_dt when dt is None).
319-328: Duplicate speculative AABB expansion block.The 10-line directed+angular speculative expansion is inlined twice (has_local_aabb branch and support-function branch). Factoring it into a small
@wp.funchelper would make both branches easier to keep in sync if bounds math evolves:♻️ Sketch
`@wp.func` def _apply_speculative_extension( lo: wp.vec3, hi: wp.vec3, v: wp.vec3, ang_ext: float, speculative_dt: float, cap_scalar: float, ) -> tuple[wp.vec3, wp.vec3]: vel_dt = v * speculative_dt ang_vec = wp.vec3(ang_ext, ang_ext, ang_ext) cap = wp.vec3(cap_scalar, cap_scalar, cap_scalar) ext_neg = wp.max(-vel_dt, wp.vec3(0.0, 0.0, 0.0)) ext_pos = wp.max(vel_dt, wp.vec3(0.0, 0.0, 0.0)) return lo - wp.min(ext_neg + ang_vec, cap), hi + wp.min(ext_pos + ang_vec, cap)Also applies to: 353-362
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@newton/_src/sim/collide.py` around lines 319 - 328, There is duplicated speculative AABB expansion logic in both the has_local_aabb and support-function branches; extract it into a small `@wp.func` (e.g. _apply_speculative_extension) that takes lo, hi, v (use shape_lin_vel[shape_id]), ang_ext (use shape_ang_speed_bound[shape_id] * speculative_dt or pass ang_ext scalar), speculative_dt and cap_scalar (max_speculative_extension) and returns the new (lo, hi) after computing vel_dt, ang_vec, cap, ext_neg/ext_pos and applying wp.min/wp.max as in the sketch; replace the duplicated blocks (the one using shape_lin_vel/shape_ang_speed_bound and the one in the support-function branch) to call this helper so both branches share the same logic.newton/_src/geometry/contact_match.py (2)
281-288: Tentativematch_indexis written even for claim losers.Pass 1 writes
data.match_index[tid] = wp.int32(best_idx)before racing for ownership viaatomic_min. Until_resolve_claims_kernelruns, every contender for the sameprev[best_idx]holds a transientmatch_index[tid] == best_idx; only the winner keeps it, the rest are demoted toMATCH_BROKENin Pass 2. This is correct because both kernels launch on the same stream and the resolve pass runs unconditionally — just worth a one-line comment noting that the intermediate value is only valid after_resolve_claims_kernelcompletes, to prevent someone from adding an early read ofmatch_indexbetween the two launches.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@newton/_src/geometry/contact_match.py` around lines 281 - 288, The current code writes a tentative match into data.match_index[tid] before racing via wp.atomic_min on data.prev_claim, so losers will temporarily hold best_idx until _resolve_claims_kernel runs and demotes them to MATCH_BROKEN; update the block around match_index assignment (referencing data.match_index, wp.atomic_min, data.prev_claim, _pack_claim, and _resolve_claims_kernel) to include a concise one-line comment clarifying that the written match_index is tentative and only valid after _resolve_claims_kernel completes, warning future readers not to read match_index between the two kernel launches.
828-836: Dummy aliases in the non-sticky path stompscratch_pos_worldacross fields.When
sticky=False, every sticky-only slot (src_offset0/1,dst_point0_body,dst_point1_body,dst_offset0_body,dst_offset1_body,dst_normal_sticky) is aliased to the sameself._sorter.scratch_pos_worldbuffer. The kernel is gated byhas_sticky==0so these slots are never read or written, which makes this safe today — but assigning fivedst_*outputs to the same array makes static/runtime alias analyzers nervous, and a future refactor that drops (or partially drops) thehas_stickyguard would silently produce concurrent writes to one buffer.Consider either using a single shared dummy alias consistently named (e.g.
_dummy_vec3 = wp.empty(1, dtype=wp.vec3)stored on the matcher) or leaving a comment right above this block noting that these aliases are only safe so long ashas_stickygates all reads/writes in_save_sorted_state_kernel.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@newton/_src/geometry/contact_match.py` around lines 828 - 836, The dummy aliases for sticky-only fields in _save_sorted_state_kernel currently all point to self._sorter.scratch_pos_world which can confuse alias analyzers and risks future concurrent writes; create a single dedicated dummy buffer on the matcher (e.g. self._dummy_vec3 = wp.empty(1, dtype=wp.vec3) initialized in the matcher/constructor) and assign data.src_offset0, data.src_offset1, data.dst_point0_body, data.dst_point1_body, data.dst_offset0_body, data.dst_offset1_body, and data.dst_normal_sticky to that _dummy_vec3 when sticky=False, and keep data.has_sticky = 0 (or alternatively add a clear comment above this block referencing _save_sorted_state_kernel and the has_sticky guard if you prefer not to add a buffer).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@CHANGELOG.md`:
- Around line 104-105: The two bullets referencing
SpeculativeContactConfig/CollisionPipeline and TRIANGLE_PRISM were mistakenly
placed under the released "## [1.1.0] - 2026-04-13" section; move those two
lines out of the 1.1.0 section and add them under the "## [Unreleased]" section
instead. Specifically, remove the entries mentioning "SpeculativeContactConfig"
and "CollisionPipeline" and the "TRIANGLE_PRISM" support-function type from the
1.1.0 list and paste them into the Unreleased section as separate bullet items
so the changelog no longer retroactively changes shipped release notes and the
TRIANGLE_PRISM entry appears only once.
In `@newton/_src/sim/contacts.py`:
- Around line 242-255: The per-frame device counters rigid_contact_new_count and
rigid_contact_broken_count are not reset by clear(), leaving stale report
values; update clear() to explicitly zero these two counters (in the same way
contact_counters are reset) so that rigid_contact_new_count and
rigid_contact_broken_count are set to 0 each clear call; ensure you handle both
places where these fields are initialized/used (the block with
rigid_contact_new_indices/new_count and the similar block around lines 293-345)
and use the same device-zeroing semantics as the other contact counters.
---
Outside diff comments:
In `@newton/_src/geometry/contact_sort.py`:
- Line 1: The SPDX header in newton/_src/geometry/contact_sort.py was changed to
2026 but must retain the file's original creation year; revert the
SPDX-FileCopyrightText line in contact_sort.py to the original single year (not
a range) that the file was first created so the header matches repository
policy.
---
Duplicate comments:
In `@newton/_src/geometry/narrow_phase.py`:
- Around line 880-894: The heightfield branch is calling
heightfield_vs_convex_midphase with only shape_gap, so speculative extension
isn't applied; change the call to pass the speculative-widened gap (e.g.,
shape_gap + shape_speculative_extension or a dedicated shape_speculative_gap)
and update heightfield_vs_convex_midphase's signature/uses to accept and apply
that speculative gap when building the heightfield cell query; keep the j!=0
early-continue logic and locate this change around GeoType.HFIELD,
heightfield_data, shape_heightfield_index and the heightfield_vs_convex_midphase
invocation.
- Around line 1835-1862: The NarrowPhase.launch() wrapper does not expose or
forward the new speculative-contact parameters to launch_custom_write
(shape_lin_vel, shape_ang_speed_bound, speculative_dt,
max_speculative_extension), so callers cannot enable speculative contacts;
update the NarrowPhase.launch() signature to accept those four parameters (with
same defaults) and pass them through when calling launch_custom_write, and
repeat the same change for the other wrapper overload around the later block
(the one covering lines ~2312-2448) so both wrappers expose and forward
shape_lin_vel, shape_ang_speed_bound, speculative_dt, and
max_speculative_extension to launch_custom_write.
In `@newton/_src/sim/collide.py`:
- Around line 928-935: The deterministic branch is being enabled for
hydroelastic scenes (per_contact_props is True when
self.narrow_phase.hydroelastic_sdf is set), which is invalid because
hydroelastic contacts produce multiple contacts per pair that keep
launch-dependent ordering; update the guard in the constructor around the
deterministic/contact-sorting logic (the block creating self._sort_key_array and
self._contact_sorter/ContactSorter) to explicitly reject or disable
deterministic mode when self.narrow_phase.hydroelastic_sdf is present (and also
when contact_matching forces deterministic=True), e.g. raise a clear exception
or fallback to deterministic=False if per_contact_props is True; reference the
deterministic flag, per_contact_props, self.narrow_phase.hydroelastic_sdf,
ContactSorter and the code path that force-sets deterministic via
contact_matching to locate and adjust the logic.
- Around line 767-810: The expert-component branch fails to validate that the
supplied NarrowPhase's speculative setting matches the pipeline's speculative
enablement; before the max_candidate_pairs check, assert that the provided
narrow_phase has a boolean attribute speculative and that
narrow_phase.speculative == self._speculative_enabled, and if not raise a
ValueError describing the mismatch (e.g., ask to pass a narrow_phase built with
speculative matching CollisionPipeline(_speculative_enabled) or adjust
speculative_config); reference the symbols narrow_phase,
self._speculative_enabled, and self.narrow_phase and perform this check prior to
the existing narrow_phase.max_candidate_pairs validation.
---
Nitpick comments:
In `@newton/_src/geometry/contact_match.py`:
- Around line 281-288: The current code writes a tentative match into
data.match_index[tid] before racing via wp.atomic_min on data.prev_claim, so
losers will temporarily hold best_idx until _resolve_claims_kernel runs and
demotes them to MATCH_BROKEN; update the block around match_index assignment
(referencing data.match_index, wp.atomic_min, data.prev_claim, _pack_claim, and
_resolve_claims_kernel) to include a concise one-line comment clarifying that
the written match_index is tentative and only valid after _resolve_claims_kernel
completes, warning future readers not to read match_index between the two kernel
launches.
- Around line 828-836: The dummy aliases for sticky-only fields in
_save_sorted_state_kernel currently all point to self._sorter.scratch_pos_world
which can confuse alias analyzers and risks future concurrent writes; create a
single dedicated dummy buffer on the matcher (e.g. self._dummy_vec3 =
wp.empty(1, dtype=wp.vec3) initialized in the matcher/constructor) and assign
data.src_offset0, data.src_offset1, data.dst_point0_body, data.dst_point1_body,
data.dst_offset0_body, data.dst_offset1_body, and data.dst_normal_sticky to that
_dummy_vec3 when sticky=False, and keep data.has_sticky = 0 (or alternatively
add a clear comment above this block referencing _save_sorted_state_kernel and
the has_sticky guard if you prefer not to add a buffer).
In `@newton/_src/sim/collide.py`:
- Around line 1067-1075: The per-call dt should be validated before being
assigned to speculative_dt; move the _validate_speculative_scalar(dt, "dt")
check to occur when dt is not None and before computing speculative_dt in the
block that checks self._speculative_enabled (using speculative_config,
speculative_dt, and max_speculative_extension) so that any caller-supplied dt is
validated first and only then used to set speculative_dt (falling back to
cfg.collision_update_dt when dt is None).
- Around line 319-328: There is duplicated speculative AABB expansion logic in
both the has_local_aabb and support-function branches; extract it into a small
`@wp.func` (e.g. _apply_speculative_extension) that takes lo, hi, v (use
shape_lin_vel[shape_id]), ang_ext (use shape_ang_speed_bound[shape_id] *
speculative_dt or pass ang_ext scalar), speculative_dt and cap_scalar
(max_speculative_extension) and returns the new (lo, hi) after computing vel_dt,
ang_vec, cap, ext_neg/ext_pos and applying wp.min/wp.max as in the sketch;
replace the duplicated blocks (the one using shape_lin_vel/shape_ang_speed_bound
and the one in the support-function branch) to call this helper so both branches
share the same logic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yml
Review profile: CHILL
Plan: Pro
Run ID: 3e5cd8ec-242d-4034-b1b2-2a8ee69a1e02
📒 Files selected for processing (15)
CHANGELOG.mddocs/api/newton.rstdocs/api/newton_geometry.rstdocs/concepts/collisions.rstnewton/__init__.pynewton/_src/geometry/__init__.pynewton/_src/geometry/contact_match.pynewton/_src/geometry/contact_reduction_global.pynewton/_src/geometry/contact_sort.pynewton/_src/geometry/narrow_phase.pynewton/_src/sim/collide.pynewton/_src/sim/contacts.pynewton/_src/sim/model.pynewton/geometry.pynewton/tests/test_contact_matching.py
✅ Files skipped from review due to trivial changes (4)
- docs/api/newton.rst
- newton/geometry.py
- docs/api/newton_geometry.rst
- newton/_src/geometry/init.py
🚧 Files skipped from review as they are similar to previous changes (4)
- newton/init.py
- newton/_src/sim/model.py
- docs/concepts/collisions.rst
- newton/_src/geometry/contact_reduction_global.py
# Conflicts: # newton/_src/sim/collide.py
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
newton/tests/test_speculative_contacts.py (2)
117-138:contacts.clear()at line 135 is redundant with the pipeline's own clearing.Per
CollisionPipeline.collide(), the contact count is reset insidecompute_shape_aabbson every call (the generation bump fuses the clear), and an explicitcontacts.clear()is only needed in theclear_buffersdebug path. Calling it here isn't wrong, but it suggests the test believes the secondcollide()wouldn't otherwise reset the counter — which it does. Safe to drop, or add a brief comment noting why the explicit clear is kept.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@newton/tests/test_speculative_contacts.py` around lines 117 - 138, The explicit call to contacts.clear() in test_speculative_dt_override is redundant because CollisionPipeline.collide() resets contact buffers (via compute_shape_aabbs) on each call; remove the contacts.clear() invocation (or if you prefer to keep it for clarity, replace it with a one-line comment referencing CollisionPipeline.collide()/compute_shape_aabbs to explain why it’s unnecessary) so the test does not imply collide() fails to reset the contact count.
467-503: Bare class bodies + module-level test registration — works, but fragile.
TestSpeculativeContactsandTestSpeculativeTunnelinghave no body (just a docstring), and the test methods are attached at module import time by thefor … add_function_test(…)loops below. That means:
- If this module is imported with devices unavailable (e.g.
get_cuda_test_devices()returning[]),TestSpeculativeTunnelingbecomes an empty class that the@unittest.skipUnless(_cuda_available, …)wrapper still works for, but without any registered methods unittest just reports no tests (no visible skip). That's usually fine, but the class-levelskipUnlessis then purely decorative.- Helper functions such as
test_speculative_disabled_no_contactsare defined at module top level with atest_*prefix. If this file is ever discovered directly by a plainunittest/pytestloader (not viaadd_function_test), those top-level functions can be picked up as standalone tests and will fail because they take(test, device). Consider renaming the helpers (e.g._test_…or_run_…) or moving them inside the class so they aren't accidentally collected.Not a blocker — just worth tightening before de-drafting.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@newton/tests/test_speculative_contacts.py` around lines 467 - 503, Top-level helper functions like test_speculative_disabled_no_contacts, test_speculative_enabled_catches_fast_object, etc. are named with a test_* prefix and can be discovered by unittest/pytest; also TestSpeculativeContacts and TestSpeculativeTunneling are bare classes relying on add_function_test to attach methods at import time which is fragile when device lists are empty. Fix by renaming the helper functions to a non-discovered prefix (e.g. _test_ or _run_) or move them inside the TestSpeculativeContacts/TestSpeculativeTunneling classes, and add a minimal no-op test method (e.g. def test_placeholder(self): pass) to each class so the `@unittest.skipUnless` on TestSpeculativeTunneling still produces a visible skip when CUDA is unavailable; update usage of add_function_test accordingly to reference the renamed functions if you choose renaming.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@newton/tests/test_speculative_contacts.py`:
- Around line 247-314: The triangle winding in _make_thin_wall_mesh is inverted
causing inward normals; fix by reversing the vertex order for every triangle in
the tris array used by _make_thin_wall_mesh (i.e., swap the winding of each
triple so each face's two triangles have their indices reversed) so that face
normals point outward; update the tris definition in _make_thin_wall_mesh
accordingly so compute_sdf_from_mesh sees the correct inside/outside
orientation.
---
Nitpick comments:
In `@newton/tests/test_speculative_contacts.py`:
- Around line 117-138: The explicit call to contacts.clear() in
test_speculative_dt_override is redundant because CollisionPipeline.collide()
resets contact buffers (via compute_shape_aabbs) on each call; remove the
contacts.clear() invocation (or if you prefer to keep it for clarity, replace it
with a one-line comment referencing
CollisionPipeline.collide()/compute_shape_aabbs to explain why it’s unnecessary)
so the test does not imply collide() fails to reset the contact count.
- Around line 467-503: Top-level helper functions like
test_speculative_disabled_no_contacts,
test_speculative_enabled_catches_fast_object, etc. are named with a test_*
prefix and can be discovered by unittest/pytest; also TestSpeculativeContacts
and TestSpeculativeTunneling are bare classes relying on add_function_test to
attach methods at import time which is fragile when device lists are empty. Fix
by renaming the helper functions to a non-discovered prefix (e.g. _test_ or
_run_) or move them inside the TestSpeculativeContacts/TestSpeculativeTunneling
classes, and add a minimal no-op test method (e.g. def test_placeholder(self):
pass) to each class so the `@unittest.skipUnless` on TestSpeculativeTunneling
still produces a visible skip when CUDA is unavailable; update usage of
add_function_test accordingly to reference the renamed functions if you choose
renaming.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yml
Review profile: CHILL
Plan: Pro
Run ID: 2b942b3f-8ad3-4990-8ce0-b48b824d69c6
📒 Files selected for processing (1)
newton/tests/test_speculative_contacts.py
| def _make_thin_wall_mesh(hx=0.02, hy=1.0, hz=1.0): | ||
| """Return a :class:`newton.Mesh` representing a thin box (wall). | ||
|
|
||
| The wall is centred at the origin with half-extents ``(hx, hy, hz)``. | ||
| Winding is CCW when viewed from the +X side so that the face normals | ||
| point outward. | ||
| """ | ||
| verts = np.array( | ||
| [ | ||
| [-hx, -hy, -hz], | ||
| [hx, -hy, -hz], | ||
| [hx, hy, -hz], | ||
| [-hx, hy, -hz], | ||
| [-hx, -hy, hz], | ||
| [hx, -hy, hz], | ||
| [hx, hy, hz], | ||
| [-hx, hy, hz], | ||
| ], | ||
| dtype=np.float32, | ||
| ) | ||
| tris = np.array( | ||
| [ | ||
| # -X face | ||
| 0, | ||
| 3, | ||
| 7, | ||
| 0, | ||
| 7, | ||
| 4, | ||
| # +X face | ||
| 1, | ||
| 5, | ||
| 6, | ||
| 1, | ||
| 6, | ||
| 2, | ||
| # -Y face | ||
| 0, | ||
| 4, | ||
| 5, | ||
| 0, | ||
| 5, | ||
| 1, | ||
| # +Y face | ||
| 3, | ||
| 2, | ||
| 6, | ||
| 3, | ||
| 6, | ||
| 7, | ||
| # -Z face | ||
| 0, | ||
| 1, | ||
| 2, | ||
| 0, | ||
| 2, | ||
| 3, | ||
| # +Z face | ||
| 4, | ||
| 7, | ||
| 6, | ||
| 4, | ||
| 6, | ||
| 5, | ||
| ], | ||
| dtype=np.int32, | ||
| ) | ||
| return newton.Mesh(verts, tris, compute_inertia=False) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Recompute the outward normals of each face from the arrays defined in
# _make_thin_wall_mesh to confirm which direction they actually point.
python - <<'PY'
import numpy as np
hx, hy, hz = 0.02, 1.0, 1.0
verts = np.array([
[-hx, -hy, -hz],
[ hx, -hy, -hz],
[ hx, hy, -hz],
[-hx, hy, -hz],
[-hx, -hy, hz],
[ hx, -hy, hz],
[ hx, hy, hz],
[-hx, hy, hz],
])
tris = np.array([
[0,3,7],[0,7,4], # -X
[1,5,6],[1,6,2], # +X
[0,4,5],[0,5,1], # -Y
[3,2,6],[3,6,7], # +Y
[0,1,2],[0,2,3], # -Z
[4,7,6],[4,6,5], # +Z
])
labels = ["-X","-X","+X","+X","-Y","-Y","+Y","+Y","-Z","-Z","+Z","+Z"]
for lbl, t in zip(labels, tris):
a, b, c = verts[t[0]], verts[t[1]], verts[t[2]]
n = np.cross(b - a, c - a)
n = n / np.linalg.norm(n)
print(f"face {lbl} tri {t.tolist()} -> normal {n}")
PYRepository: newton-physics/newton
Length of output: 599
Triangle winding in _make_thin_wall_mesh is completely inverted — all face normals point inward instead of outward.
All 12 triangles (6 faces × 2 tris per face) have normals pointing toward the box interior:
- -X face: computed normal is +X (should be -X)
- +X face: computed normal is -X (should be +X)
- -Y face: computed normal is +Y (should be -Y)
- +Y face: computed normal is -Y (should be +Y)
- -Z face: computed normal is +Z (should be -Z)
- +Z face: computed normal is -Z (should be +Z)
The docstring's claim that winding produces outward normals is false. This breaks the mesh-based SDF collision path (_run_mesh_box_vs_mesh_wall_sim line 415) because compute_sdf_from_mesh will build a signed-distance field with inside/outside semantics reversed. The speculative gap and penetration calculations will operate on an inverted SDF, making collision detection unreliable.
Fix by inverting each triangle's vertex order:
Suggested fix
tris = np.array(
[
# -X face
0,
- 3,
- 7,
+ 7,
+ 3,
0,
- 7,
4,
+ 7,
# +X face
1,
- 5,
- 6,
+ 6,
+ 5,
1,
- 6,
2,
+ 6,
# -Y face
0,
- 4,
- 5,
+ 5,
+ 4,
0,
- 5,
1,
+ 5,
# +Y face
3,
- 2,
- 6,
+ 6,
+ 2,
3,
- 6,
7,
+ 6,
# -Z face
0,
- 1,
- 2,
+ 2,
+ 1,
0,
- 2,
3,
+ 2,
# +Z face
4,
- 7,
- 6,
+ 6,
+ 7,
4,
- 6,
5,
+ 6,
],🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@newton/tests/test_speculative_contacts.py` around lines 247 - 314, The
triangle winding in _make_thin_wall_mesh is inverted causing inward normals; fix
by reversing the vertex order for every triangle in the tris array used by
_make_thin_wall_mesh (i.e., swap the winding of each triple so each face's two
triangles have their indices reversed) so that face normals point outward;
update the tris definition in _make_thin_wall_mesh accordingly so
compute_sdf_from_mesh sees the correct inside/outside orientation.
Description
Add support for speculative contacts. They are the easiest way to get a decent amount of continuous collision detection for fast moving objects while keeping the complexity low.
Checklist
CHANGELOG.mdhas been updated (if user-facing change)Test plan
New feature / API change
Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Tests